OS (27) 분산 파일 시스템의 표준, Sun NFS(Network File System) 완벽 분석
분산 파일 시스템의 표준, Sun NFS(Network File System) 완벽 분석
오늘은 운영체제 역사상 가장 성공적인 분산 파일 시스템 중 하나인 Sun Microsystems의 NFS(Network File System) 에 대해 깊이 있게 다뤄보려 합니다.
분산 시스템, 특히 파일 시스템 영역은 '성능'과 '안정성(Crash Recovery)' 사이에서 끊임없이 줄타기해야 하는 복잡한 분야입니다. NFS가 이 문제를 어떻게 해결했는지, 특히 '상태를 유지하지 않는(Stateless)' 설계 철학이 시스템의 복구와 성능에 어떤 영향을 미쳤는지 상세히 분석해 보겠습니다. 이 글은 OSTEP(Operating Systems: Three Easy Pieces)의 51장 내용을 바탕으로 작성되었습니다.
1. 분산 파일 시스템(Distributed File System)의 기본 개념
NFS를 이해하기 전, 먼저 분산 파일 시스템의 기본 구조를 잡고 가야 합니다. 분산 클라이언트-서버 컴퓨팅이 처음 도입되었을 때, 가장 먼저 적용된 분야 중 하나가 바로 '파일 시스템'입니다.
1.1 기본 구조
기본적인 환경은 다수의 클라이언트 기계 와 하나(혹은 소수)의 서버 로 구성됩니다.
- 서버(Server): 데이터를 디스크에 저장하고 관리합니다.
- 클라이언트(Client): 약속된 프로토콜(메시지 포맷)에 따라 서버에 데이터를 요청합니다.
1.2 왜 분산 파일 시스템을 사용하는가?
로컬 디스크를 놔두고 굳이 네트워크를 통해 파일을 관리하는 이유는 명확합니다.
- 데이터 공유의 용이성: 클라이언트 A가 작업한 파일을 클라이언트 B가 마치 자신의 로컬 파일인 것처럼 즉시 접근할 수 있습니다.
- 중앙 집중형 관리: 데이터 백업을 수많은 클라이언트에서 각각 수행할 필요 없이, 서버에서만 수행하면 됩니다.
- 보안: 서버들을 물리적으로 안전한 곳(잠겨 있는 방 등)에 모아두면 도난이나 파손 같은 문제로부터 데이터를 보호하기 쉽습니다.
1.3 투명성(Transparency)
분산 파일 시스템의 가장 큰 목표는 '투명성' 입니다. 클라이언트 응용 프로그램 입장에서는 자신이 접근하는 파일이 로컬 디스크에 있는지, 네트워크 건너편 서버에 있는지 알 필요가 없어야 합니다.
따라서 클라이언트 프로그램은 기존의 open(), read(), write(), close() 같은 표준 시스템 콜을 그대로 사용합니다. 단지 내부적으로 클라이언트 측 파일 시스템이 이 요청을 가로채서 네트워크 메시지로 변환하여 서버에 보낼 뿐입니다.
2. Sun NFS(Network File System)의 탄생과 철학
Sun Microsystems가 개발한 NFS는 분산 시스템의 가장 대표적인 성공 사례입니다. 그 성공의 이면에는 독특한 전략과 명확한 설계 철학이 있었습니다.
2.1 오픈 프로토콜(Open Protocol) 전략
Sun은 NFS를 개발하면서 내부 구현을 감추는 독점 방식을 버렸습니다. 대신 클라이언트와 서버 간의 통신 메시지 형식을 정의하고 이를 전면 공개 했습니다. 이로 인해 다른 회사들(IBM, Oracle, NetApp 등)도 이 규약만 따른다면 독자적인 NFS 서버를 개발할 수 있었고, 상호 운용이 가능해졌습니다. 이는 NFS가 시장의 표준으로 자리 잡는 데 결정적인 역할을 했습니다.
2.2 핵심 설계 목표: "서버 크래시에서의 빠르고 단순한 복구"
NFSv2(버전 2) 프로토콜 설계의 핵심 질문은 이것이었습니다.
서버가 죽었다 살아났을 때, 어떻게 하면 가장 빠르고 간단하게 복구할 수 있을까?
서버는 언제든 멈출 수 있습니다. 전원 공급이 중단될 수도 있고, 소프트웨어 버그나 메모리 누수로 인해 크래시가 발생할 수도 있습니다. 만약 서버가 복잡한 상태 정보를 가지고 있다면, 재부팅 후 이 정보를 복구하는 과정은 악몽이 될 것입니다. NFS는 이 문제를 해결하기 위해 Stateless(무상태) 아키텍처를 선택했습니다.
3. 핵심 아키텍처: Stateless (상태를 유지하지 않음)
NFS를 이해하는 가장 중요한 키워드는 Stateless 입니다.
3.1 Stateful vs Stateless
일반적인 로컬 파일 시스템은 Stateful(상태 유지) 입니다.
예를 들어 open() 시스템 콜을 생각해보겠습니다.
- 클라이언트가
open("foo", O_RDONLY)를 호출합니다. - 파일 시스템은 파일 디스크립터(fd)를 반환하고, 커널 메모리에 이 fd가 가리키는 파일이 무엇인지, 현재 읽기 위치(Offset)가 어디인지 를 저장합니다.
- 이후
read(fd)를 호출하면, 커널은 저장된 상태 정보를 보고 "아, 이 파일의 100번지부터 읽을 차례구나"라고 판단하여 데이터를 읽고 오프셋을 갱신합니다.
하지만 NFS 서버는 이러한 상태 정보를 전혀 저장하지 않습니다. 서버는 클라이언트가 어떤 파일을 열고 있는지, 현재 오프셋이 어디인지 모릅니다. 심지어 클라이언트가 파일을 열었는지조차 모릅니다.
3.2 어떻게 상태 없이 통신하는가?
서버가 상태를 모르는데 어떻게 읽기/쓰기가 가능할까요? 답은 간단합니다. 클라이언트가 요청을 보낼 때, 필요한 모든 정보를 매번 포함해서 보낸다.
NFS 프로토콜에서 클라이언트는 read 요청을 보낼 때 다음과 같이 말하지 않습니다.
- (X) "지난번에 읽은 곳 다음부터 읽어줘."
- (O) 파일 핸들 X의 1024번 오프셋부터 4096바이트를 읽어줘.
이렇게 하면 서버는 이전에 무슨 일이 있었는지 기억할 필요가 없습니다. 그저 들어온 요청을 처리하고 응답하면 끝입니다.
3.3 파일 핸들(File Handle)
NFS에서 fd(파일 디스크립터) 역할을 하는 것이 바로 파일 핸들(File Handle) 입니다.
파일 핸들은 다음 세 가지 정보로 구성됩니다.
- 볼륨 식별자(Volume Identifier): 요청이 어떤 파일 시스템 파티션을 대상으로 하는지 나타냅니다.
- 아이노드 번호(Inode Number): 해당 파티션 내에서 정확히 어떤 파일인지를 식별합니다.
- 생성 번호(Generation Number): 아이노드 재사용 문제를 방지하기 위한 번호입니다.
※ 생성 번호가 왜 필요한가? UNIX 기반 파일 시스템에서는 파일을 삭제하고 새 파일을 만들 때, 기존에 썼던 아이노드 번호를 재사용하는 경우가 많습니다. 만약 클라이언트 A가 옛날 파일의 파일 핸들(아이노드 100번)을 들고 있는데, 서버에서 그 파일이 삭제되고 새로운 파일(아이노드 100번)이 생성되었다면? 클라이언트 A는 엉뚱한 파일을 읽거나 쓰게 될 수 있습니다. 이를 막기 위해 아이노드가 재사용될 때마다 증가하는 '생성 번호'를 두어, 이 번호가 맞지 않으면 접근을 거부합니다.
4. NFS 프로토콜의 동작 과정
실제 클라이언트가 파일을 읽을 때 어떤 일이 벌어지는지 단계별로 살펴보겠습니다.
-
Open:
- 클라이언트 앱이
open("/foo/bar.txt")를 호출합니다. - 클라이언트 커널(NFS 클라이언트)은 서버에게 LOOKUP 메시지를 보냅니다. (루트 디렉터리 핸들 + "foo")
- 서버는 "foo"의 파일 핸들을 줍니다. 다시 "foo"의 핸들 + "bar.txt"로 LOOKUP 을 보냅니다.
- 최종적으로 "bar.txt"의 파일 핸들 을 얻습니다.
- 중요: 이때 서버는 파일을 열었다는 사실을 기록하지 않습니다. 핸들만 던져줄 뿐입니다.
- 클라이언트 앱이
-
Read:
- 클라이언트 앱이
read(fd, buffer, 1024)를 호출합니다. - 클라이언트 NFS는 내부적으로 현재 오프셋(예: 0)을 관리하고 있습니다.
- 서버에게 READ(파일 핸들, offset=0, count=1024) 메시지를 보냅니다.
- 서버는 데이터를 읽어서 반환합니다.
- 클라이언트는 데이터를 앱에 전달하고, 내부 오프셋을 1024로 업데이트합니다.
- 클라이언트 앱이
-
서버 크래시 발생 시:
- 만약
read요청을 보냈는데 서버가 죽었다면? - 클라이언트는 타임아웃 후 단순히 요청을 재전송 합니다.
- 서버가 재부팅되면, 들어온 요청(파일 핸들, 오프셋 포함)을 보고 처리해주면 됩니다. 복구 과정이 필요 없습니다. 이것이 Stateless 설계의 강력함입니다.
- 만약
5. 멱등성(Idempotency): 실패를 다루는 방법
네트워크 통신은 불안정합니다. 요청이 가다가 사라질 수도 있고, 응답이 오다가 사라질 수도 있습니다. NFS는 이 문제를 멱등성(Idempotency) 이라는 성질을 통해 해결합니다.
5.1 멱등연산이란?
멱등연산이란 연산을 한 번 수행한 결과와, 여러 번 반복해서 수행한 결과가 동일한 연산 을 의미합니다.
a = 10;(메모리에 값 저장) -> 멱등연산입니다. 100번을 수행해도 a는 10입니다.a = a + 1;(카운터 증가) -> 멱등연산이 아닙니다. 수행 횟수에 따라 결과가 달라집니다.
5.2 NFS에서의 멱등성
NFS의 주요 연산들은 대부분 멱등연산이 되도록 설계되었습니다.
- LOOKUP/READ: 단순히 정보를 읽는 것이므로 몇 번을 해도 결과가 같습니다.
- WRITE: 가장 중요한 부분입니다. NFS의
WRITE는 "현재 위치에서 데이터를 써라"가 아니라, 오프셋 1000번지에 데이터 X를 써라 라는 식입니다. 따라서 같은 요청을 10번 보내도, 파일의 내용은 변하지 않고 동일하게 유지됩니다.
5.3 멱등성이 해결하는 3가지 실패 케이스
클라이언트가 요청을 보내고 응답을 못 받은 상황은 크게 3가지입니다. NFS는 이 3가지를 '무조건 재전송' 이라는 하나의 단순한 로직으로 모두 해결합니다.
- 요청 패킷 손실: 서버에 도달조차 못했습니다. -> 재전송하면 서버가 처음 받아서 처리합니다. (OK)
- 서버 다운: 요청을 처리하기 전 서버가 죽었습니다. -> 재전송하면 살아난 서버가 처리합니다. (OK)
- 응답 패킷 손실: 서버는 처리를 완료했는데, "성공" 응답이 오다가 사라졌습니다. -> 재전송하면 서버가 다시 똑같은 연산 을 수행합니다. 멱등성 덕분에 파일이 망가지지 않고, 클라이언트는 성공 응답을 결국 받게 됩니다. (OK)
참고: mkdir 같은 연산은 멱등성을 만들기 어렵습니다. 이미 만들어진 디렉터리를 또 만들라고 하면 실패 에러가 뜨기 때문입니다. 하지만 NFS는 이런 사소한 예외보다는 전체적인 시스템의 단순함을 선택했습니다.
6. 성능 문제와 클라이언트 측 캐싱(Caching)
모든 파일 접근 요청을 네트워크로 보내면 성능이 끔찍하게 느려집니다. 이를 해결하기 위해 NFS 클라이언트는 캐싱(Caching) 을 적극적으로 사용합니다. 한 번 읽은 데이터는 메모리에 저장해두고, 쓰기 작업도 즉시 서버에 보내지 않고 모았다가 보냅니다(Write Buffering).
하지만 캐싱은 분산 시스템의 영원한 난제인 '캐시 일관성(Cache Consistency)' 문제를 야기합니다.
6.1 문제 1: 갱신 가시성 (Update Visibility)
클라이언트 A가 파일에 데이터를 쓰고(버퍼링 중), 아직 서버로 보내지 않았습니다. 이때 클라이언트 B가 그 파일을 읽으면? B는 A가 쓴 내용을 볼 수 없습니다. 예전 데이터를 읽게 됩니다. 즉, 하나의 파일에 대해 서로 다른 내용을 보게 되는 것입니다.
[해결책: 닫을 때 내보냄 (Flush-on-Close)]
NFS는 Close-to-Open 일관성 모델을 사용합니다.
응용 프로그램이 파일을 close() 하는 순간, 클라이언트는 버퍼에 있는 모든 변경 사항을 강제로 서버에 전송(Flush)합니다. 따라서 누군가 파일을 닫았다면, 그 이후에 파일을 여는(open) 다른 클라이언트는 최신 내용을 볼 수 있음이 보장됩니다.
(단점: 임시 파일을 만들고 지우는 짧은 작업조차도 무조건 서버 전송을 강제하므로 성능 저하가 발생할 수 있습니다.)
6.2 문제 2: 오래된 캐시 (Stale Cache)
클라이언트 A가 파일을 읽어서 캐시에 저장해뒀습니다(버전 1). 그런데 클라이언트 B가 그 파일을 수정해서 서버에 저장했습니다(버전 2). 클라이언트 A가 다시 그 파일을 읽으려 할 때, 자기 캐시에 있는 버전 1을 그대로 쓰면 안 됩니다.
[해결책: 속성 검사 (GETATTR Check)] 클라이언트는 캐시된 데이터를 사용하기 전에, 서버에게 GETATTR 요청을 보냅니다. "이 파일의 마지막 수정 시간이 언제니?"라고 물어보고, 내가 가진 캐시보다 최신이면 캐시를 버리고 새로 읽어옵니다.
6.3 딜레마와 속성 캐시(Attribute Cache)
그런데 매번 데이터를 읽을 때마다 GETATTR을 서버에 보내면 캐시의 의미가 퇴색됩니다. 네트워크 트래픽이 여전히 발생하기 때문입니다.
그래서 NFS는 '속성 캐시' 를 도입합니다. 파일의 속성(수정 시간 등) 정보도 약 3초 정도 캐시해두는 것입니다.
즉, 3초 동안은 서버에 물어보지 않고 "파일이 안 바뀌었을 거야"라고 가정하고 그냥 로컬 데이터를 씁니다.
이로 인해 3초간의 '일관성 구멍' 이 생깁니다. 누군가 파일을 수정해도 다른 클라이언트는 최대 3초 동안 그 사실을 모를 수 있습니다. 하지만 NFS는 성능을 위해 이 정도의 불완전함은 허용하는 설계 철학(Voltaire's Law: 완벽은 좋음의 적이다)을 따릅니다.
7. 서버 측 쓰기 버퍼링과 안정성
마지막으로 서버 측의 이슈를 살펴보겠습니다. 성능을 위해 서버도 디스크에 쓰기 전 메모리에 데이터를 잠시 보관(Write Buffering)하고 싶을 것입니다. 하지만 NFS 서버는 절대로 데이터를 메모리에만 쓴 상태에서 클라이언트에게 '성공' 응답을 보내선 안 된다 는 철칙이 있습니다.
7.1 왜 메모리에만 쓰고 응답하면 안 되는가?
다음과 같은 시나리오를 상상해봅시다.
- 클라이언트가 데이터 덩어리 A, B, C를 순서대로 보냅니다.
- 서버가 A를 받고 디스크에 썼습니다. (성공 응답)
- 서버가 B를 받고 메모리에만 쓴 뒤 성공 응답을 보냈습니다. (성능을 위해)
- 서버가 C를 받고 디스크에 썼습니다. (성공 응답)
- 이때 서버가 크래시(전원 차단) 됩니다!
- 메모리에 있던 B는 사라집니다.
서버가 재부팅되면 파일의 내용은 A - (옛날 데이터) - C가 되어버립니다.
클라이언트는 B도 성공했다는 응답을 받았기 때문에 재전송을 하지 않습니다. 결과적으로 데이터가 조용히 파손되는 심각한 문제가 발생합니다.
7.2 해결책: 커밋(Commit) 후 응답
따라서 NFS 서버는 WRITE 요청이 오면 반드시 디스크 같은 안정적인 저장 장치(Stable Storage)에 데이터를 완전히 기록한 후에만 클라이언트에게 ACK(응답)를 보냅니다.
이로 인해 쓰기 성능이 디스크 속도에 종속되어 느려질 수 있습니다. 이를 극복하기 위해 NetApp 같은 기업들은 배터리가 내장된 NVRAM(비휘발성 램) 을 사용하여, 디스크에 쓰는 것만큼 안전하면서도 메모리만큼 빠른 쓰기 성능을 구현하기도 했습니다.
8. 요약 및 결론
Sun의 NFS는 분산 파일 시스템의 교과서와 같은 시스템입니다. 오늘 학습한 내용을 요약하면 다음과 같습니다.
- Stateless 아키텍처: 서버는 클라이언트의 상태를 저장하지 않습니다. 이를 통해 서버 크래시 발생 시 별도의 복구 과정 없이 즉시 서비스를 재개할 수 있습니다.
- 멱등성(Idempotency): 네트워크나 서버 장애 시 클라이언트는 단순히 요청을 재전송하는 것으로 문제를 해결합니다. 이를 위해 주요 연산(Write 포함)은 여러 번 수행해도 결과가 같도록 설계되었습니다.
- 파일 핸들:
open()의 결과로 상태(fd) 대신, 파일의 위치 정보를 완벽히 담은 핸들을 사용하여 통신합니다. - 캐싱과 일관성: 성능을 위해 클라이언트 캐싱을 사용하며,
Close-to-Open정책과GETATTR타임아웃을 통해 일관성을 '적당한 수준'에서 타협했습니다. - 서버의 책임: 데이터 무결성을 위해 서버는 반드시 데이터를 안정적인 저장소에 기록한 후 응답해야 합니다.
NFS는 완벽한 일관성을 포기하는 대신 단순함(Simplicity) 과 견고함(Robustness), 그리고 성능(Performance) 을 얻었습니다. 이것이 바로 NFS가 수십 년이 지난 지금까지도 표준으로 살아남은 이유입니다.
이 글이 NFS의 내부 동작 원리를 이해하는 데 도움이 되었기를 바랍니다.
